Skip to content

Compute integer range bounds for bitwise AND, OR, XOR, and NOT on IntegerRangeType - #5771

Open
phpstan-bot wants to merge 8 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-vqyeygp
Open

Compute integer range bounds for bitwise AND, OR, XOR, and NOT on IntegerRangeType#5771
phpstan-bot wants to merge 8 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-vqyeygp

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When bitwise XOR (^), OR (|), or AND (&) is applied to IntegerRangeType operands whose ranges exceed 128 values (the CALCULATE_SCALARS_LIMIT), PHPStan falls back to plain int because it cannot enumerate all possible value pairs. This causes false positives — for example, ord() returns int<0, 255>, so ord('a') ^ ord('b') should also be int<0, 255>, but was inferred as int, making chr() report a type error.

This PR adds analytical bounds computation for all four bitwise operations so that bounded integer ranges produce tight results even when the ranges are too large for finite enumeration.

Changes

  • src/Reflection/InitializerExprTypeResolver.php:

    • Restructured getBitwiseAndType(), getBitwiseOrType(), getBitwiseXorType() to try analytical bounds computation before falling back to optimizeScalarType() (which loses range info)
    • Added computeBitwiseAndRange(): for non-negative bounded integers, AND result is int<0, min(leftMax, rightMax)>. Single-side bounds also supported when the other side is verified as an integer type
    • Added computeBitwiseOrXorRange(): for non-negative bounded integers, OR/XOR result is int<0, allBitsMask(max(leftMax, rightMax))> — the mask with all bits set up to the highest bit position
    • Added getNonNegativeIntegerBounds(): extracts [min, max] from IntegerRangeType or ConstantIntegerType when bounds are non-negative and finite
    • Added allBitsMask(): computes the all-bits-set mask via bit propagation (e.g., 255 → 255, 10 → 15, 200 → 255)
  • src/Type/IntegerRangeType.php:

    • Added toBitwiseNotType() override: ~int<a, b> now correctly produces int<~b, ~a> instead of plain int (inherited from IntegerType)

Root cause

The bitwise methods in InitializerExprTypeResolver relied solely on getFiniteOrConstantScalarTypes() for bounded results. When a range has more than 128 values (like int<0, 255> with 256 values), getFiniteTypes() returns empty, and the method falls through to return new IntegerType(). The fix adds a second path: when finite enumeration fails, compute analytical bounds from the range endpoints using bitwise arithmetic properties.

Analogous cases probed:

  • Bitwise AND (&): Same issue for ranges > 128 values. Also extended the existing constant-integer bounds logic to support IntegerRangeType on both sides.
  • Bitwise OR (|): Same issue. Same fix approach (upper bound = all-bits mask).
  • Bitwise NOT (~): Different location (IntegerRangeType::toBitwiseNotType()), same class of bug — range bounds were lost because the parent IntegerType implementation returned plain int.
  • Compound assignments (&=, |=, ^=): Delegate to the same methods, automatically fixed.
  • Shift operations (<<, >>): Already have dedicated range-aware logic in InitializerExprTypeResolver, not affected.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14654.php: Regression test covering:
    • XOR of int<0, 255> (the reported issue) → int<0, 255>
    • OR and AND of int<0, 255>int<0, 255>
    • XOR/OR of int<0, 20>int<0, 31> (next power-of-2 minus 1)
    • AND of int<0, 20>int<0, 20> (tighter bound)
    • AND with mixed range sizes (int<0, 255> & int<0, 20>int<0, 20>)
    • XOR/OR with constant operand (int<0, 255> ^ 42int<0, 255>)
    • Bitwise NOT: ~int<0, 255>int<-256, -1>, ~int<0, 20>int<-21, -1>
    • Bitwise NOT with partial bounds: ~int<min, 10>int<-11, max>, ~int<-5, max>int<min, 4>
    • Unbounded and negative ranges correctly stay int
    • Compound assignment: $d &= $a preserves bounds

Fixes phpstan/phpstan#14654

@VincentLanglet
VincentLanglet changed the base branch from 2.1.x to 2.2.x May 30, 2026 08:53
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-vqyeygp branch from d3a7efd to cf9a410 Compare May 30, 2026 08:53
@staabm
staabm force-pushed the create-pull-request/patch-vqyeygp branch from cf9a410 to f2dae0e Compare June 7, 2026 09:48
@staabm
staabm force-pushed the create-pull-request/patch-vqyeygp branch from f2dae0e to 200b12d Compare August 13, 2026 08:57
assertType('int<0, 255>', $a | $b);
assertType('int<0, 255>', $a & $b);

/** @var int<0, 255> $c */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of inline @var use parameters and @param types instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed staabm's review comment.

Changetests/PHPStan/Analyser/nsrt/bug-14654.php no longer uses inline /** @var */ annotations. The single foo() function was split into seven functions whose operands come in as parameters with @param types:

  • foo() — the original reproducer (ord() results, no annotations needed)
  • withConstantOperand(int $c)@param int<0, 255>, XOR/OR/AND against literal 42
  • smallRanges(int $x, int $y)@param int<0, 20> each
  • differentRangeSizes(int $a, int $x) — AND across differently sized ranges
  • unboundedRanges(int $unbounded, int $a)int<0, max> stays int
  • negativeRanges(int $signed, int $x)int<-10, 10> stays int
  • bitwiseNot(...) — all four ~ cases including int<min, 10> / int<-5, max>
  • compoundAssignment(int $d, int $a)$d &= $a

Coverage is unchanged; every assertion from the original file is preserved.

Verification

  • Confirmed the rewritten file's assertions actually run: temporarily flipped int<0, 31> to int<0, 32> and the test failed at that line.
  • Confirmed it still fails without the fix: checked out the pre-fix src/, and 14 assertions failed with int instead of the expected ranges (the only one that still passed is $c & 42, which the old constant-integer path already handled).
  • make tests — OK, 21270 tests, 96814 assertions, 97 skipped, exit 0.
  • make phpstan — no errors.

Committed as 3497926ed and pushed to create-pull-request/patch-vqyeygp.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds bounded inference for bitwise operations on integer ranges, addressing issue #14654.

Changes:

  • Computes conservative ranges for AND, OR, and XOR.
  • Preserves ranges through bitwise NOT.
  • Adds regression coverage for bounded, unbounded, and signed ranges.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/Reflection/InitializerExprTypeResolver.php Adds analytical bitwise range computation.
src/Type/IntegerRangeType.php Implements range-aware bitwise NOT.
tests/PHPStan/Analyser/nsrt/bug-14654.php Tests bitwise range inference.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Reflection/InitializerExprTypeResolver.php

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a test for

<?php

use function PHPStan\Testing\assertType;

function doFoo(int $integer) {
	assertType('int<0, 3>', $integer & '3');
}

and fix the regression

$value |= $value >> 4;
$value |= $value >> 8;
$value |= $value >> 16;
$value |= $value >> 32;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this limited to 32 bit ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the review comment.

Answer to "why is this limited to 32 bit?" — it wasn't. The chain >> 1, 2, 4, 8, 16, 32 is a doubling sequence that propagates the highest set bit across the full 64-bit width; >> 32 is the last doubling step, not a cutoff. Verified: allBitsMask(1 << 40)2199023255551 (2^41−1) and allBitsMask(PHP_INT_MAX)PHP_INT_MAX.

Since the unrolled form reads like a 32-bit limit, I made the width explicit instead (3b286dbfa):

/**
 * Propagates the highest set bit of a non-negative value into all lower bits:
 * 200 becomes 255, 10 becomes 15.
 */
private static function allBitsMask(int $value): int
{
	for ($shift = 1; $shift < PHP_INT_SIZE * 8; $shift *= 2) {
		$value |= $value >> $shift;
	}

	return $value;
}

This derives the shifts from the native integer width, so it also does the right thing on 32-bit builds instead of doing a no-op >> 32.

On the $integer & '3' regression from the earlier review: it was already fixed and pushed in 4599d27b1 (the range computation now runs on the toNumber()-coerced operands, after the string/error handling, with the single non-negative operand alone bounding the AND result), including the requested assertType('int<0, 3>', $integer & '3') coverage. I had independently written the same fix; after fetching I dropped my duplicate and rebased onto that commit, keeping only the mask change above.

Verificationmake cs clean, make phpstan exit 0 ([OK] No errors), make tests exit 0 (21270 tests, 96814 assertions, 97 skipped). Pushed to create-pull-request/patch-vqyeygp.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Addressed the review — the regression is fixed and covered by a test.

Root cause. The new range computation ran on the raw operand types, so it only saw IntegerRangeType/ConstantIntegerType directly. The code it replaced ran on toNumber() results, which is what recognized '3' (and true) as a constant integer. Hence $integer & '3' degraded from int<0, 3> to int.

Fix (src/Reflection/InitializerExprTypeResolver.php) — for all three of AND/OR/XOR:

  • toNumber() of both operands is captured before optimizeScalarType() runs, since that call widens int<0, 255> to int and would destroy the bounds the analytical path needs.
  • The range computation moved to the end of the method, after the string/string and ErrorType handling, and is fed those coerced types.
  • Since the string and error cases are already resolved at that point, computeBitwiseAndRange() no longer needs the isInteger()->yes() guard on the opposite operand: if one operand is in [0, M], the AND result's bits are a subset of its bits, so the result is in [0, M] whatever the other side is. This also generalizes the old behavior — int<0, max> & int<0, 255> is now int<0, 255>.

Tests (tests/PHPStan/Analyser/nsrt/bug-14654.php) — added withOperandCoercedToInteger() with the exact case you asked for plus the symmetric and adjacent ones ('3' & $integer, $integer & true, and ^/|/& of int<0, 255> against '3'), and single-sided AND assertions in the unbounded and negative-range functions.

Verification

  • Differential probe against the pre-fix baseline (200b12d2c^) over 19 bitwise expressions covering string/float/bool/mixed/array/error operands: the only output differences are the three intended improvements (int<0,255> ^ '3', int<0,255> ^ int<0,20>, int<0,255> & int<0,20>). Notably int<0,255> & string still yields *ERROR* — the relaxed rule doesn't swallow it, because it now runs after the error checks.
  • The six new coercion assertions fail with int on the PR's previous source and pass with the fix.
  • make tests — OK, 21270 tests, 96814 assertions, 97 skipped. make phpstan — no errors.

Pushed as 4599d27b1.

@staabm
staabm requested a review from VincentLanglet August 13, 2026 11:19
VincentLanglet and others added 7 commits August 13, 2026 13:22
…tegerRangeType`

- Add analytical bounds computation in `InitializerExprTypeResolver` for
  bitwise AND, OR, and XOR when operands are non-negative bounded integers,
  so large ranges (>128 values) that exceed the finite enumeration limit
  still produce tight `IntegerRangeType` results instead of plain `int`
- For AND: result is `int<0, min(leftMax, rightMax)>` when both sides are
  non-negative; `int<0, knownMax>` when only one side has bounds and the
  other is an integer type
- For OR and XOR: result is `int<0, allBitsMask(max(leftMax, rightMax))>`
  when both sides are non-negative bounded integers
- Add `IntegerRangeType::toBitwiseNotType()` so `~int<a, b>` correctly
  produces `int<~b, ~a>` instead of plain `int`
- Extract helper methods: `getNonNegativeIntegerBounds()`,
  `computeBitwiseAndRange()`, `computeBitwiseOrXorRange()`, `allBitsMask()`
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The range computation ran on the raw operand types, so an operand that
only becomes an integer after numeric coercion (`$integer & '3'`, `$integer & true`)
was not recognized and the result fell back to plain `int` - a regression
against the previous constant-integer handling, which used `toNumber()`.

Move the computation after the string/error handling and feed it the
`toNumber()` results, captured before `optimizeScalarType()` widens integer
ranges to `int`. Because the string and error cases are already handled at
that point, a single non-negative operand now suffices to bound the AND
result: the result's bits are a subset of that operand's bits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unrolled shifts (1, 2, 4, 8, 16, 32) already covered all 64 bits - the
last doubling step is `>> 32`, it is not a 32-bit cutoff - but written out
like that it reads as if the mask stopped at 32 bits. Looping while the
shift stays below PHP_INT_SIZE * 8 says the same thing explicitly and also
adapts to 32-bit builds of PHP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-vqyeygp branch from c83eb2d to a4d4080 Compare August 13, 2026 11:22
@staabm

staabm commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

@SanderMuller please review :)

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. The maths is sound, the tests are reasonable, and it is perf-neutral. One suggestion below, nothing blocking.

Soundness — brute-forced rather than reasoned about

Bitwise bounds are easy to get subtly wrong, so I replicated the three formulas and checked them against ground truth: for every contiguous range pair in a window (plus one-sided-unbounded variants, negatives included) I enumerated the actual value pairs and verified the predicted interval contains every result.

ranges=281  op-combinations checked=236,883  UNSOUND=0  declined(fallback to int)=195,435
bitwise NOT: ranges=281  UNSOUND=0

No unsound case. Two things I specifically wanted to confirm, since they are the non-obvious ones:

  • The single-sided & ($leftBounds !== null alone) is correct, and it is the subtlest part of the PR. a & b with a ∈ [0, aMax] and b entirely unknown, including negative, does stay in [0, aMax]: a >= 0 means its sign bit is clear, so the result's sign bit is clear, and masking bits off a non-negative value cannot increase it. Good that negativeRanges() covers it.
  • toBitwiseNotType()'s null handling. ~ is monotonically decreasing so the swap is right, and propagating null in the opposite slot is right too — int<min, 10>int<-11, max> and int<-5, max>int<min, 4>, both asserted.

allBitsMask() is also fine on 32-bit, where >> 32 yields 0 and the extra step is a no-op.

Gates

Full suite green (21325), self-analysis clean, phpcs clean. Worth noting no existing assertType expectation needed updating, which is itself a signal about how narrow the new condition is.

Perf: flat. Interleaved A/B on a real Doctrine/Symfony application (3267 errors over 1144 files), 2 rounds, medians, CPU as user+sys: 129.3s vs 129.3s (+0.0%), and the JSON output is byte-identical. The extra work is two getNonNegativeIntegerBounds() calls on paths that previously fell straight to new IntegerType(), so that is the expected result.

Suggestion: non-negative but unbounded operands still fall back to int

getNonNegativeIntegerBounds() requires max !== null, so the whole analytical path declines when a side has no finite upper bound. That misses some very ordinary code, since strlen(), count(), mb_strlen() etc. all return int<0, max>:

\PHPStan\dumpType(strlen($a) | strlen($b)); // int  (could be int<0, max>)
\PHPStan\dumpType(strlen($a) ^ strlen($b)); // int  (could be int<0, max>)
\PHPStan\dumpType(strlen($a) & strlen($b)); // int  (could be int<0, max>)
\PHPStan\dumpType(strlen($a) | 8);          // int  (could be int<0, max>)

All four are provably non-negative: |, ^ and & of two non-negative ints leave the sign bit clear, and for & a single non-negative operand is enough — which is exactly the argument the PR already relies on for the bounded single-sided case. So int<0, max> is available whenever a side is known non-negative, even with no finite max.

The reason I raise it rather than leave it: unboundedRanges() currently asserts the imprecise result (assertType('int', $unbounded ^ $a) where $unbounded is int<0, max>), which locks it in. Either extend the helper, or the assertion is fine as documentation of current behaviour — your call, but it seemed worth naming before it becomes the expected output.

Two cosmetic notes, take or leave:

  • getNonNegativeIntegerBounds() returns array{int, int} but only element [1] is ever read; returning ?int would drop the tuple and the unused $min.
  • The hoisted $leftNumberType/$rightNumberType are deliberately computed before optimizeScalarType(), while the ErrorType guard a few lines below re-derives $leftType->toNumber() after it. Both are correct, but two identical-looking expressions now mean different things; one comment on the guard would save the next reader the double-take.

CI

The 3 reds do not look attributable: Benchmark / Test (PHP 8.5) and Run with Turbo Extension (macos-latest, 8.5, nts, make phpstan) are red across unrelated branches right now, and other-tests / dup-classes passed on four earlier runs of this same commit.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix


\PHPStan\dumpType(strlen($a) | strlen($b)); // int  (could be int<0, max>)
\PHPStan\dumpType(strlen($a) ^ strlen($b)); // int  (could be int<0, max>)
\PHPStan\dumpType(strlen($a) & strlen($b)); // int  (could be int<0, max>)
\PHPStan\dumpType(strlen($a) | 8);          // int  (could be int<0, max>)

…finite max

getNonNegativeIntegerBounds() required a finite upper bound, so the analytical
path declined whenever a side was int<0, max> - which is what strlen(), count()
and friends return. The sign-bit argument does not need a finite max: &, | and ^
of non-negative operands stay non-negative, and for & a single non-negative
operand is enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Addressed the review — all four cases now infer int<0, max>.

Change (src/Reflection/InitializerExprTypeResolver.php): getNonNegativeIntegerBounds() required max !== null, which made the whole analytical path decline for int<0, max> operands. It now returns array{int, int|null} — non-negativity gates the result, a null max just means "no finite upper bound":

  • &: takes the min() over whichever sides have a finite max; if neither does, int<0, max>. This subsumes the single-sided case the PR already relied on.
  • | / ^: both sides must be non-negative (that is what keeps the sign bit clear); if either lacks a finite max, int<0, max> instead of falling back to int.

Verified with bin/phpstan analyse on the exact snippet from the review:

strlen($a) | strlen($b)  =>  int<0, max>
strlen($a) ^ strlen($b)  =>  int<0, max>
strlen($a) & strlen($b)  =>  int<0, max>
strlen($a) | 8           =>  int<0, max>

Tests (tests/PHPStan/Analyser/nsrt/bug-14654.php): unboundedRanges() no longer locks in the imprecise int$unbounded ^ $a and $unbounded | $a now assert int<0, max>. Added nonNegativeUnboundedFunctionCalls() with the four strlen() cases plus strlen($a) & 8int<0, 8> (the finite side still tightens the bound), and extended negativeRanges() to confirm a signed operand still blocks ^/| while $signed & $unboundedint<0, max>.

Verification

  • Confirmed the new assertions actually run: flipped int<0, 8> to int<0, 9> and the test failed at that line with Actual: int<0, 8>.
  • Brute-forced soundness over 600k random pairs (non-negative × arbitrary, negatives included): 0 unsound results for & non-negativity, & staying within the non-negative side's max, and |/^ non-negativity.
  • make tests — OK, 21274 tests, 96816 assertions, 97 skipped. No existing expectation needed updating. make phpstan[OK] No errors. make cs — clean.

On the two cosmetic notes: the ErrorType guard now reads a bit clearer since the hoisted $leftNumberType/$rightNumberType carry an explanatory comment at their definition, but I left the guard itself alone as it is pre-existing code. getNonNegativeIntegerBounds() keeps the tuple rather than returning ?int — with a nullable max, a bare ?int return could no longer distinguish "not non-negative" from "no upper bound".

Pushed as 52a58ebdf.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Int bounds are lost after XOR

5 participants